--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit e9c94e3310a864db9e7dd797e9fe73c571b7a2be
Parents : 4b822f1
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-20T16:20:08-05:00
feat: add RRC room keys schema and API contract for room keys endpoint
Changes
5 files changed, 58 insertions(+), 37 deletions(-)
Diff
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 05ae1c66..4069457e 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/tests/backend/http_api_response_registry.py b/tests/backend/http_api_response_registry.py
index 11157f8c..3c5f1206 100644
--- a/tests/backend/http_api_response_registry.py
+++ b/tests/backend/http_api_response_registry.py
@@ -101,6 +101,7 @@ from tests.backend.http_api_response_schemas import (
RRC_HUBS_SCHEMA,
RRC_MEMBERS_SCHEMA,
RRC_MESSAGES_SCHEMA,
+ RRC_ROOM_KEYS_SCHEMA,
RRC_SERVERS_SCHEMA,
SERVER_SECURITY_SCHEMA,
SPAM_KEYWORDS_SCHEMA,
@@ -413,6 +414,14 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
),
HttpJsonContract("GET", "/api/v1/rrc/hubs", RRC_HUBS_SCHEMA),
HttpJsonContract("GET", "/api/v1/rrc/servers", RRC_SERVERS_SCHEMA),
+ HttpJsonContract(
+ "GET",
+ "/api/v1/rrc/hubs/{hub_hash}/room-keys",
+ RRC_ROOM_KEYS_SCHEMA,
+ match_info={"hub_hash": _HEX32},
+ allow_statuses=(200, 404),
+ alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,),
+ ),
HttpJsonContract(
"GET",
"/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages",
diff --git a/tests/backend/http_api_response_schemas.py b/tests/backend/http_api_response_schemas.py
index 83fc5f2e..5433446f 100644
--- a/tests/backend/http_api_response_schemas.py
+++ b/tests/backend/http_api_response_schemas.py
@@ -523,6 +523,13 @@ RRC_ACTIVITY_SCHEMA: dict = {
"additionalProperties": True,
}
+RRC_ROOM_KEYS_SCHEMA: dict = {
+ "type": "object",
+ "required": ["keys"],
+ "properties": {"keys": _ARRAY},
+ "additionalProperties": True,
+}
+
RNCP_STATUS_SCHEMA: dict = {
"type": "object",
"additionalProperties": True,
diff --git a/tests/backend/test_telephone_audio_ws.py b/tests/backend/test_telephone_audio_ws.py
index 54d2f5ab..4c36ab47 100644
--- a/tests/backend/test_telephone_audio_ws.py
+++ b/tests/backend/test_telephone_audio_ws.py
@@ -26,6 +26,29 @@ def web_audio_app(mock_app):
return app
+def _bridge_with_clients(*, config_enabled=True, attach_ok=True, send_ready=None):
+ """Build a bridge mock whose clients set matches real attach/detach semantics."""
+ clients = set()
+
+ def attach_client(ws):
+ if not attach_ok:
+ return False
+ clients.add(ws)
+ return True
+
+ def detach_client(ws):
+ clients.discard(ws)
+
+ bridge = MagicMock()
+ bridge.clients = clients
+ bridge.config_enabled.return_value = config_enabled
+ bridge.send_status = AsyncMock(side_effect=send_ready) if send_ready else AsyncMock()
+ bridge.attach_client.side_effect = attach_client
+ bridge.detach_client.side_effect = detach_client
+ bridge.push_client_frame = MagicMock()
+ return bridge
+
+
@pytest.mark.asyncio
async def test_telephone_audio_ws_disabled_config_returns_error(
web_audio_app,
@@ -33,12 +56,7 @@ async def test_telephone_audio_ws_disabled_config_returns_error(
):
monkeypatch.setattr(web_audio_app, "web_audio_required", lambda: False)
- bridge = MagicMock()
- bridge.config_enabled.return_value = False
- bridge.send_status = AsyncMock()
- bridge.attach_client.return_value = False
- bridge.detach_client = MagicMock()
- bridge.push_client_frame = MagicMock()
+ bridge = _bridge_with_clients(config_enabled=False, attach_ok=False)
web_audio_app.web_audio_bridge = bridge
aio_app = _build_aio_app(web_audio_app)
@@ -50,7 +68,8 @@ async def test_telephone_audio_ws_disabled_config_returns_error(
assert msg["type"] == "error"
assert "disabled" in msg["message"].lower()
bridge.send_status.assert_not_called()
- bridge.detach_client.assert_called_once()
+ # Early close: never attached, so detach is not required.
+ bridge.detach_client.assert_not_called()
@pytest.mark.asyncio
@@ -63,12 +82,11 @@ async def test_telephone_audio_ws_allowed_on_chaquopy_when_config_disabled(
async def send_ready(ws):
await ws.send_str(json.dumps({"type": "web_audio.ready", "frame_ms": 60}))
- bridge = MagicMock()
- bridge.config_enabled.return_value = False
- bridge.send_status = AsyncMock(side_effect=send_ready)
- bridge.attach_client.return_value = False
- bridge.detach_client = MagicMock()
- bridge.push_client_frame = MagicMock()
+ bridge = _bridge_with_clients(
+ config_enabled=False,
+ attach_ok=False,
+ send_ready=send_ready,
+ )
web_audio_app.web_audio_bridge = bridge
aio_app = _build_aio_app(web_audio_app)
@@ -90,12 +108,7 @@ async def test_telephone_audio_ws_no_active_call_reports_attach_error(web_audio_
async def send_ready(ws):
await ws.send_str(json.dumps({"type": "web_audio.ready", "frame_ms": 60}))
- bridge = MagicMock()
- bridge.config_enabled.return_value = True
- bridge.send_status = AsyncMock(side_effect=send_ready)
- bridge.attach_client.return_value = False
- bridge.detach_client = MagicMock()
- bridge.push_client_frame = MagicMock()
+ bridge = _bridge_with_clients(attach_ok=False, send_ready=send_ready)
web_audio_app.web_audio_bridge = bridge
aio_app = _build_aio_app(web_audio_app)
@@ -103,6 +116,7 @@ async def test_telephone_audio_ws_no_active_call_reports_attach_error(web_audio_
ws = await client.ws_connect("/ws/telephone/audio")
msg1 = await ws.receive_json()
msg2 = await ws.receive_json()
+ await ws.send_bytes(b"\x01\x02\x03")
await ws.close()
assert msg1["type"] == "web_audio.ready"
@@ -110,6 +124,7 @@ async def test_telephone_audio_ws_no_active_call_reports_attach_error(web_audio_
assert "no active call" in msg2["message"].lower()
bridge.attach_client.assert_called_once()
bridge.detach_client.assert_called_once()
+ bridge.push_client_frame.assert_not_called()
@pytest.mark.asyncio
@@ -117,12 +132,7 @@ async def test_telephone_audio_ws_ping_and_attach_messages(web_audio_app):
async def send_ready(ws):
await ws.send_str(json.dumps({"type": "web_audio.ready", "frame_ms": 60}))
- bridge = MagicMock()
- bridge.config_enabled.return_value = True
- bridge.send_status = AsyncMock(side_effect=send_ready)
- bridge.attach_client.return_value = True
- bridge.detach_client = MagicMock()
- bridge.push_client_frame = MagicMock()
+ bridge = _bridge_with_clients(send_ready=send_ready)
web_audio_app.web_audio_bridge = bridge
aio_app = _build_aio_app(web_audio_app)
@@ -145,12 +155,7 @@ async def test_telephone_audio_ws_binary_messages_forward_frames(web_audio_app):
async def send_ready(ws):
await ws.send_str(json.dumps({"type": "web_audio.ready", "frame_ms": 60}))
- bridge = MagicMock()
- bridge.config_enabled.return_value = True
- bridge.send_status = AsyncMock(side_effect=send_ready)
- bridge.attach_client.return_value = True
- bridge.detach_client = MagicMock()
- bridge.push_client_frame = MagicMock()
+ bridge = _bridge_with_clients(send_ready=send_ready)
web_audio_app.web_audio_bridge = bridge
aio_app = _build_aio_app(web_audio_app)
@@ -169,12 +174,7 @@ async def test_telephone_audio_ws_bad_json_does_not_crash_handler(web_audio_app)
async def send_ready(ws):
await ws.send_str(json.dumps({"type": "web_audio.ready", "frame_ms": 60}))
- bridge = MagicMock()
- bridge.config_enabled.return_value = True
- bridge.send_status = AsyncMock(side_effect=send_ready)
- bridge.attach_client.return_value = True
- bridge.detach_client = MagicMock()
- bridge.push_client_frame = MagicMock()
+ bridge = _bridge_with_clients(send_ready=send_ready)
web_audio_app.web_audio_bridge = bridge
aio_app = _build_aio_app(web_audio_app)
diff --git a/tests/frontend/AppIdentitySwitch.test.js b/tests/frontend/AppIdentitySwitch.test.js
index 27689f09..01ad42ab 100644
--- a/tests/frontend/AppIdentitySwitch.test.js
+++ b/tests/frontend/AppIdentitySwitch.test.js
@@ -29,6 +29,7 @@ function makeCtx() {
getConfig: vi.fn().mockResolvedValue(undefined),
updateRingtonePlayer: vi.fn().mockResolvedValue(undefined),
getAppInfo: vi.fn().mockResolvedValue(undefined),
+ getBlockedDestinations: vi.fn().mockResolvedValue(undefined),
updateTelephoneStatus: vi.fn(),
$t: (key) => key,
};
@@ -39,11 +40,13 @@ describe("App.vue applyIdentitySwitched", () => {
vi.clearAllMocks();
GlobalState.unreadConversationsCount = 3;
GlobalState.missedCallsCount = 2;
+ GlobalState.blockedDestinations = [{ destination_hash: "old" }];
});
afterEach(() => {
GlobalState.unreadConversationsCount = 0;
GlobalState.missedCallsCount = 0;
+ GlobalState.blockedDestinations = [];
vi.useRealTimers();
});
@@ -59,7 +62,9 @@ describe("App.vue applyIdentitySwitched", () => {
expect(ctx.getConfig).toHaveBeenCalledTimes(1);
expect(ctx.updateRingtonePlayer).toHaveBeenCalledTimes(1);
expect(ctx.getAppInfo).toHaveBeenCalledTimes(1);
+ expect(ctx.getBlockedDestinations).toHaveBeenCalledTimes(1);
expect(ctx.updateTelephoneStatus).toHaveBeenCalledTimes(1);
+ expect(GlobalState.blockedDestinations).toEqual([]);
expect(ctx.isSwitchingIdentity).toBe(false);
expect(GlobalEmitter.emit).toHaveBeenCalledWith(
"identity-switched",
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────